Skip to content

feat(svm): add Swig smart wallet support via transaction flattening#1380

Open
maxsch-xmint wants to merge 13 commits intox402-foundation:mainfrom
maxsch-xmint:add-swig-instructions-validation
Open

feat(svm): add Swig smart wallet support via transaction flattening#1380
maxsch-xmint wants to merge 13 commits intox402-foundation:mainfrom
maxsch-xmint:add-swig-instructions-validation

Conversation

@maxsch-xmint
Copy link
Copy Markdown

@maxsch-xmint maxsch-xmint commented Feb 27, 2026

Description

feat(svm): add Swig smart wallet support via transaction flattening

Summary

  • Add support for Swig smart wallet transactions in the SVM facilitator verification pipeline
  • Introduce a transaction flattening strategy that decodes Swig SignV2 instructions into regular instructions the facilitator can verify
  • Implement a normalizer strategy pattern (SwigNormalizer / RegularNormalizer) to transparently handle both Swig and regular transactions
  • Support multiple SignV2 instructions per transaction, enforcing that all reference the same Swig PDA
  • Mirror the implementation across TypeScript, Go, and Python SDKs with consistent behavior and error messages

Motivation

Swig smart wallets wrap user operations inside a SignV2 instruction that bundles compact sub-instructions, account lists, and a secp256r1 signature verification. A raw Swig transaction looks nothing like a regular SPL token transfer, so the existing facilitator verification logic (which expects a standard TransferChecked at a known position) rejects them outright.

Rather than special-casing the verification logic, we flatten Swig transactions into the same instruction layout the facilitator already understands, then let the existing checks run unchanged.

Approach

Swig transaction layout

A Swig transaction arriving at the facilitator has this shape:

[0..N]   ComputeBudget instructions (SetComputeUnitLimit, SetComputeUnitPrice)
[...]    Secp256r1Precompile (passkey signature verification)
[...]    One or more Swig SignV2 instructions
           accounts: [SwigPDA, SwigWalletAddr, ...referenced accounts]
           data:     [discriminator(11) | payloadLen | roleId | compact instructions...]

Each SignV2 instruction embeds compact instructions — a packed binary format where each sub-instruction references accounts by index into the SignV2's own account list.

Flattening strategy

parseSwigTransaction transforms this into:

[0..N]   ComputeBudget instructions (passed through unchanged)
[...]    Resolved instructions from SignV2 #1's compact payload
[...]    Resolved instructions from SignV2 #2's compact payload (if present)
...

Secp256r1 precompile instructions are filtered out (they are Swig internals). Each compact instruction's local account indices are resolved to actual addresses through the SignV2's account list.

Normalizer pattern

The facilitator's verify() pipeline calls normalizeTransaction(), which tries each normalizer in order:

  1. SwigNormalizer — if isSwigTransaction() returns true, flatten via parseSwigTransaction() and set payer to the Swig PDA
  2. RegularNormalizer — fallback; extract payer from the first TransferChecked instruction's owner account and pass instructions through unchanged

This keeps the verification logic clean — it always receives a flat instruction list regardless of whether the original transaction was Swig or regular.

New functions

isSwigTransaction(instructions)boolean

Determines whether a transaction has the Swig layout. Iterates all instructions and returns true only when:

  • Every instruction is one of: ComputeBudget, Secp256r1Precompile, or Swig SignV2
  • At least one Swig SignV2 instruction is present
  • Each Swig instruction has data >= 2 bytes with discriminator == 11 (SignV2)

parseSwigTransaction(instructions, staticAccounts){ instructions, swigPda }

Flattens a Swig transaction into regular instructions:

  1. Single-pass separation: collect compute budgets, skip precompiles, collect SignV2s
  2. For each SignV2: extract PDA from accounts[0], validate wallet address derivation via getProgramDerivedAddress, decode compact instructions, resolve account indices
  3. Enforce all SignV2 instructions reference the same Swig PDA
  4. Return flattened instruction array + the shared swigPda

decodeSwigCompactInstructions(data)SwigCompactInstruction[]

Decodes the binary compact instruction format embedded in SignV2 data:

  • Skips discriminator (2 bytes) + payload length (2 bytes) + role ID (4 bytes)
  • Reads instruction count, then for each: programIdIndex (u8), accounts (u8[]), data length (u16 LE), raw data

Changes

File Change
typescript/packages/mechanisms/svm/src/utils.ts isSwigTransaction, parseSwigTransaction, decodeSwigCompactInstructions
typescript/packages/mechanisms/svm/src/normalizer.ts SwigNormalizer, RegularNormalizer, normalizeTransaction
typescript/packages/mechanisms/svm/src/constants.ts SWIG_PROGRAM_ADDRESS, SWIG_SIGN_V2_DISCRIMINATOR, SECP256R1_PRECOMPILE_ADDRESS
typescript/packages/mechanisms/svm/src/index.ts Re-export new modules
typescript/packages/mechanisms/svm/src/exact/facilitator/scheme.ts Call normalizeTransaction() in verify pipeline
go/mechanisms/svm/swig.go IsSwigTransaction, ParseSwigTransaction, DecodeSwigCompactInstructions
go/mechanisms/svm/constants.go Swig program ID, discriminator, precompile address
go/mechanisms/svm/normalizer.go SwigNormalizer, RegularNormalizer, NormalizeTransaction
go/mechanisms/svm/exact/facilitator/scheme.go Call NormalizeTransaction() in verify pipeline
typescript/packages/mechanisms/svm/test/unit/facilitator.test.ts 25+ Swig test cases
go/test/unit/svm_test.go Go Swig test cases
python/x402/mechanisms/svm/constants.py SWIG_PROGRAM_ADDRESS, SWIG_SIGN_V2_DISCRIMINATOR, SECP256R1_PRECOMPILE_ADDRESS
python/x402/mechanisms/svm/swig.py New: is_swig_transaction, parse_swig_transaction, decode_swig_compact_instructions
python/x402/mechanisms/svm/normalizer.py New: SwigNormalizer, RegularNormalizer, normalize_transaction
python/x402/mechanisms/svm/__init__.py Re-export new constants + functions
python/x402/mechanisms/svm/exact/facilitator.py Call normalize_transaction() in verify pipeline
python/x402/tests/unit/mechanisms/svm/test_swig.py New: 21 Swig test cases

Security invariants

Check Behavior
Instruction whitelist isSwigTransaction only allows ComputeBudget, Secp256r1Precompile, Swig SignV2 — rejects anything else
PDA wallet derivation Each SignV2's accounts[1] is cross-validated against getProgramDerivedAddress(SWIG_PROGRAM, ["swig-wallet-address", pda])
PDA consistency All SignV2 instructions must reference the same Swig PDA; throws swig_pda_mismatch otherwise
Index bounds checking Compact instruction account indices are validated against SignV2's account list length
Authority check Facilitator rejects transactions where fee payer equals authority (Swig PDA), preventing self-signing
Mint / destination / amount Verified from flattened instructions using existing facilitator logic — unchanged
Transaction simulation Final verify step simulates the transaction on-chain to catch runtime errors

Tests

isSwigTransaction

  • Valid Swig transaction with compute budgets + SignV2 returns true
  • Valid with secp256r1 precompile instructions returns true
  • Returns false when last instruction is not Swig
  • Returns false when non-allowed instruction is present
  • Returns false for unknown discriminator (only V2, not V1)
  • Returns false for empty instructions
  • Returns false when Swig instruction data is too short
  • Returns true for transaction with 2 SignV2 instructions

decodeSwigCompactInstructions

  • Throws when data shorter than 4 bytes
  • Throws when instructionPayloadLen exceeds available data
  • Correctly decodes single TransferChecked compact instruction
  • Throws when compact instruction data is truncated

parseSwigTransaction

  • Flattens Swig transaction with embedded TransferChecked
  • Filters out secp256r1 precompile instructions
  • Resolves compact instruction account indices to addresses
  • Extracts swigPda from first account of SignV2
  • Throws when compact instruction index exceeds SignV2 accounts
  • Flattens transaction with 2 SignV2 instructions, same swigPda
  • Throws when 2 SignV2 instructions have different swigPdas
  • Throws when SwigWalletAddress doesn't match expected derivation

normalizeTransaction

  • Dispatches to SwigNormalizer for Swig transactions
  • Dispatches to RegularNormalizer for non-Swig transactions

Added tests for a real Swig transaction parsing. Explorer link

CI

  • All 138+ TypeScript tests pass
  • Go tests pass
  • Python tests pass (21 new swig tests + 687 full suite)
  • TypeScript compiles with no errors (tsc --noEmit)
  • Go compiles with no errors (go build ./...)

Checklist

  • I have formatted and linted my code
  • All new and existing tests pass
  • My commits are signed (required for merge) -- you may need to rebase if you initially pushed unsigned commits
  • I added a changelog fragment for user-facing changes (docs-only changes can skip)

@cb-heimdall
Copy link
Copy Markdown

cb-heimdall commented Feb 27, 2026

🟡 Heimdall Review Status

Requirement Status More Info
Reviews 🟡 0/1
Denominator calculation
Show calculation
1 if user is bot 0
1 if user is external 0
2 if repo is sensitive 0
From .codeflow.yml 1
Additional review requirements
Show calculation
Max 0
0
From CODEOWNERS 0
Global minimum 0
Max 1
1
1 if commit is unverified 1
Sum 2

@github-actions github-actions Bot added typescript go sdk Changes to core v2 packages labels Feb 27, 2026
@maxsch-xmint maxsch-xmint marked this pull request as draft February 27, 2026 16:22
@vercel
Copy link
Copy Markdown

vercel Bot commented Feb 27, 2026

@maxsch-xmint is attempting to deploy a commit to the Coinbase Team on Vercel.

A member of the Team first needs to authorize it.

@maxsch-xmint maxsch-xmint changed the title Swig support: add static validation for swig transactions feat(svm): add Swig smart wallet support via transaction flattening Feb 27, 2026
Copy link
Copy Markdown
Contributor

@notorious-d-e-v notorious-d-e-v left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work @maxsch-xmint!

Overall I agree with this approach and would rather allowlist known Solana programs rather than keeping it open to any arbitrary program.

Sorry if the review sounds wordy or written by AI -- I promise that I made multiple passes with multiple AI models :)


The flattening approach is a clean architectural choice — normalizing Swig transactions before verification avoids branching in every validation step. That said, there are:

  • two parsing bugs that could cause this to produce incorrect results on real Swig transactions
  • a scalability concern with how this sets precedent for future wallet types
  • and some smaller code quality issues.

🔴 Critical: Compact instruction parsing doesn't match the on-chain format

As far as I can telll, there are two bugs in DecodeSwigCompactInstructions / decodeSwigCompactInstructions that cause the parser to produce incorrect results for real Swig transactions. The unit tests pass because the test helpers construct synthetic data with the same structural errors.

Bug 1: Missing numInstructions count byte

The Swig contract serializes compact instructions with a leading u8 count byte (compact_instructions.rs:164-175):

pub fn into_bytes(&self) -> Vec<u8> {
    let mut bytes = vec![self.inner_instructions.len() as u8];  // ← count byte
    for ix in self.inner_instructions.iter() {
        bytes.push(ix.program_id_index);
        bytes.push(ix.accounts.len() as u8);
        bytes.extend(ix.accounts.iter());
        bytes.extend((ix.data.len() as u16).to_le_bytes());
        bytes.extend(ix.data.iter());
    }
    bytes
}

And the on-chain parser confirms this (lib.rsInstructionIterator::new):

cursor: 1, // Start AFTER the number of instructions byte
remaining: unsafe { *data.get_unchecked(0) } as usize, // byte 0 is the count

The actual payload format starting at offset 8 is:

[0]         numInstructions  U8       ← PR skips this
[1]         programIdIndex   U8
[2]         numAccounts      U8
...

But the PR starts reading directly at offset 8, treating the count byte as the first instruction's programIdIndex:

// Go — swig.go
offset := startOffset  // = 8
for offset < endOffset {
    programIDIndex := data[offset]  // ← this is actually the count byte
// TypeScript — utils.ts
let offset = startOffset; // = 8
while (offset < endOffset) {
    const programIdIndex = data[offset]; // ← this is actually the count byte

With a real Swig transaction containing 1 embedded transferChecked, the count byte 0x01 gets read as programIdIndex = 1, and every subsequent field is shifted by one byte — producing wrong instruction data.

Bug 2: Compact indices need remapping through SignV2's account list

In the Swig SDK, compact instruction indices reference positions in the SignV2 instruction's account list, not the transaction's global static accounts. This is visible in how the SDK builds them (compact_instructions.rs:35-72) — indices are assigned into a local accounts vector that becomes SignV2's account list. On-chain, the iterator resolves them against all_accounts passed to the SignV2 instruction (lib.rsparse_next_instruction):

let program_id = self.accounts.get_account(program_id_index as usize)?.pubkey();
//                   ^^^^^^^^^ SignV2's accounts, NOT tx static accounts

The correct resolution is: compact_index → signV2.accounts[compact_index] → tx.staticAccounts[that_value]

But the PR resolves directly: compact_index → tx.staticAccounts[compact_index]

// Go — swig.go:305-311
result = append(result, solana.CompiledInstruction{
    ProgramIDIndex: uint16(ci.ProgramIDIndex),  // ← treated as global index
    Accounts:       accounts,                    // ← treated as global indices
// TypeScript — utils.ts:941-949
programAddress: staticAccounts[ci.programIdIndex],       // should be staticAccounts[signV2.accounts[ci.programIdIndex]]
accounts: ci.accounts.map(idx => ({
    address: staticAccounts[idx],                         // should be staticAccounts[signV2.accounts[idx]]

These only align when the SignV2 instruction's account ordering happens to match the transaction's static account ordering, which is not guaranteed and not how the Swig SDK constructs transactions.

Security implications of both bugs combined

The facilitator's static checks (mint, destination ATA, amount, authority) operate on the flattened instructions, while simulation runs on the real transaction.

Simulation only proves execution success — not that the mint, destination, and amount are what the facilitator expects.

If broken index resolution produces addresses that happen to satisfy the static checks, the facilitator would signal to a merchant that the payment was made to the merchant whereas the actual on-chain execution pays a different recipient or amount than what verification believed.

Suggested fix:

  1. Consume and validate the numInstructions count byte at the start of the compact payload
  2. Remap compact indices through signV2.accounts[] before resolving against global static accounts

🟡 Unit tests need to match the real transaction layout

The test helpers (buildSwigInstructionData in Go, buildSwigData/buildTransferCheckedCompact in TS) construct synthetic Swig data that omits the count byte and uses direct indices — matching the buggy parser rather than the actual on-chain format. Once the parsing bugs above are fixed, the test data must be updated to:

  1. Prepend the numInstructions count byte to the compact instruction payload
  2. Use indices relative to a synthetic SignV2 account list, then include that account list in the test transaction so the remapping can be verified end-to-end

🟡 Missing Python implementation

Recent PRs to the coinbase/x402 repo have included implementations across TypeScript, Go, and Python. The Python SDK has a full SVM facilitator (python/x402/mechanisms/svm/exact/facilitator.py) with the same instruction layout validation (3-6 instructions, compute budget checks, transfer verification, simulation). It would reject Swig transactions for the same structural mismatch reasons. Can we include a Python implementation here for completeness and cross-SDK parity?


🟡 Does this need x402 v1 support?

The v1 SVM facilitator exists in both TypeScript (svm/src/exact/v1/facilitator/scheme.ts) Go (svm/exact/v1/facilitator/scheme.go), and Python with the same rigid 3-6 instruction layout validation.

Currently only x402 v2 gets Swig support. Should v1 be included as well? @CarsonRoscoe @phdargen — would appreciate your input on the v1 question.


🟡 Design: Scalability for future smart wallet types

This PR establishes the pattern for how x402 handles non-standard transaction layouts. If more smart wallets are added in the future (Squads, etc.), the current approach produces a growing if/else chain in Verify():

if (isSwigTransaction(instructions)) {
  // ...
} else if (isSquadsTransaction(instructions)) {
  // ...
} else if (isFutureWalletTransaction(instructions)) {
  // ...
} else {
  // regular path
}

Each wallet type adds detection, parsing, constants, and compact instruction decoding — all duplicated across Go, TypeScript, and Python, all inlined in the facilitator.

Consider formalizing the flattening concept behind a normalizer interface:

interface TransactionNormalizer {
  canHandle(instructions: CompiledInstruction[]): boolean;
  normalize(instructions: CompiledInstruction[], accounts: Address[]): {
    instructions: CompiledInstruction[];
    payer: string;
  };
}

The facilitator becomes wallet-agnostic (normalizers.find(n => n.canHandle(...))), and each wallet type is a self-contained module. Since this PR is setting the precedent, it's worth getting the abstraction right from the start.


🟡 Other issues

  • Bare string throw (TS utils.ts:273): throw "invalid_exact_svm_payload_no_transfer_instruction" — every other error in the package uses throw new Error(...). A bare string loses the stack trace and won't match error instanceof Error checks (e.g., scheme.ts:473-474).

  • as never type casts (TS scheme.ts:154-155): isSwigTransaction(instructions as never) and parseSwigTransaction(instructions as never, ...) bypass type safety. The function signatures should accept the actual decompiled instruction type if possible, rather than requiring the caller to escape the type system.

  • Inconsistent error handling between SDKs: Go's DecodeSwigCompactInstructions returns an error when data is <4 bytes. TypeScript's version silently returns an empty array. A malformed Swig transaction would fail differently in each SDK.

  • No bounds checking on staticAccounts[ci.programIdIndex] (TS): If a compact instruction references an index beyond staticAccounts.length, this silently returns undefined and produces confusing downstream errors.

  • signV1 mentioned in comments but unsupported: Multiple docstrings reference "signV1/signV2" but only V2 (discriminator 11) is supported. The comments should clarify that signV1 is intentionally excluded.

  • No PDA validation: The Swig PDA is extracted from signV2.accounts[0] and trusted as-is. An explicit findProgramAddress derivation check would add defense-in-depth.

  • Missing changeset/changelog fragments: The PR checklist requires changeset/changelog entries.

@github-actions github-actions Bot added the python label Mar 3, 2026
@maxsch-xmint maxsch-xmint marked this pull request as ready for review March 3, 2026 01:55
@notorious-d-e-v
Copy link
Copy Markdown
Contributor

@phdargen @CarsonRoscoe can you please take a look when you get a chance?

@BranchManager69
Copy link
Copy Markdown
Contributor

BranchManager69 commented Mar 8, 2026

Good work on the Swig support @maxsch-xmint.

We've been running a different approach to smart wallet verification in production at Dexter that I think is worth considering for the upstream facilitator. It handles all smart wallet types generically: no per-wallet parsers or binary format decoders.

The problem with per-wallet normalization

Each new wallet type needs its own canHandle() + normalize() implementation, its own binary decoder, and its own index remapping logic, all duplicated across TypeScript, Go, and Python. The Swig normalizer alone is 3,264 lines across three SDKs. Adding Squads, SPL Governance, or any future wallet means repeating this for each one.

Simulation-based outcome verification

We verify payment outcomes by examining the simulation output rather than parsing each wallet type's proprietary instruction format.

The facilitator already calls simulateTransaction as the final step of verify(). By requesting innerInstructions: true in that same RPC call, we get the full CPI trace, including any TransferChecked executed by the smart wallet program at any depth. No additional round-trip.

The pipeline:

  1. Try the existing static path (instruction count, program allowlist, positional TransferChecked). If it passes, done — standard wallet.
  2. If static validation rejects due to an unknown program, fall through to simulation:
    • Fee payer isolation: the facilitator's fee payer must not appear in any instruction's accounts (prevents all drain vectors)
    • Compute budget caps: operator-configurable CU and priority fee limits (bounds fee exposure)
    • Simulate with innerInstructions: true: extract all TransferChecked from the CPI trace
    • Match exactly one transfer against payment requirements (mint, destination ATA, exact amount)

This works for any wallet program that executes TransferChecked via CPI. Every smart wallet does this; it's how SPL token transfers work regardless of the wrapping program.

Ecosystem results

We tested this against the ecosystem Solana v2 facilitators using a real Squads multisig vault transaction (verify and settle):

Facilitator Verify Settle Detail
Dexter PASS PASS Full settlement on-chain
Corbits FAIL Invalid transaction
PayAI FAIL no_transfer_instruction
Ultraviolet FAIL Failed to deserialize
Daydreams FAIL Unauthorized
OpenFacilitator PASS FAIL No verification; settlement fails with InstructionError

Next step

The simulation approach handles all smart wallet types, including Swig, with a single code path and no per-wallet parsing. Since the facilitator already pays the cost of simulateTransaction on every verify call, requesting innerInstructions: true adds no meaningful latency.

PR with this approach: #1527

@BranchManager69
Copy link
Copy Markdown
Contributor

PR with the simulation-based approach: #1527

@BranchManager69
Copy link
Copy Markdown
Contributor

@maxsch-xmint your issue #1458 (allowing wallet provider fee instructions) is another case where the positional allowlist would need expanding. The simulation-based approach in #1527 handles this without allowlist changes — it verifies exactly one TransferChecked matching payment requirements and is agnostic to any other instructions in the transaction.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

go python sdk Changes to core v2 packages typescript

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants